OOP - 4 抽象類別與介面 (1)


Posted by tsungtingdu on 2021-09-19

在上一篇文章中提到,我們可以將不同類別當中的共同屬性或方法,提取出來放在 parent 類別當中,然後透過繼承的方式,實現這些屬性或方法,同時也可以加入額外的屬性或方法。

以上次提到例子來說,BaseballPlayer 是一個 parent 類別,包含了 name 屬性和 hit 方法

class BaseballPlayer {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit`)
  }
}

接著,我們建立 Shortstop 類別來繼承 BaseballPlayer

class Shortstop extends BaseballPlayer {
  run() {
    console.log(`${this.name} can run`)
  }
}

當然我們也可以建立另外一個 Outfielder 類別來繼承 BaseballPlayer,創造出更多不同類型的 baseball players

class Outfielder extends BaseballPlayer {
  run() {
    console.log(`${this.name} can run very fast`)
  }
}

最後,我們就可以實際創造出類別的實例,並呼叫其方法

const lindor = new Shortstop('lindor')
const betts = new Outfielder('betts')

lindor.hit()  // lindor can hit
lindor.run()  // lindor can run
betts.hit()   // betts can hit
betts.run()   // betts can run very fast

奇怪的繼承

這時候,隔壁棚傳來新的需求,想要建立一個同樣可以實作出 name 屬性和 hit 方法的網球選手,於是就直接讓 TennisPlayer 去繼承 BaseballPlayer

class TennisPlayer extends BaseballPlayer {
  serve() {
    console.log(`${this.name} can serve`)
  }
}

const federer = new Golfer('federer')
federer.hit()                         // johnny can hit
federer.walk()                        // johnny can serve

網球選手繼承棒球選手?雖然實作出來的結果如預期,但是看起來就非常的奇怪,而且沒有邏輯。如果今天我們繼續充實 BaseballPlayer 類別,譬如加入 pitch 方法,變成

class BaseballPlayer {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit`)
  }

  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

結果就會發現網球選手 federer 也會開始投球了

const federer = new Golfer('federer')
federer.pitch()                       // federer can pitch

A 是 B 的一種

雖然我們可以任意的分類、抽取屬性和方法建立 parent 類別,然後任意的繼承某個類別來取得需要的屬性和方法,但這樣的「整理方式」,最終只會造成無限的混亂和錯誤發生。

所以通常在建立 parent 類別和繼承的時候,會遵循著「A 是 B 的一種」(is-a)的規則,譬如

  • Shortstop 是 BaseballPlayer 的一種
  • Outfielder 是 BaseballPlayer 的一種
  • 智人是人屬的一種
  • 靈長目是哺乳綱的一種
  • child 類別是 parent 類別的一種
  • ...

這樣一來,就能有邏輯的模擬真實世界的狀況,也不會有意外的錯誤發生。

如何整理和規範?

現在我們知道 TennisPlayer 不應該直接繼承 BaseballPlayer,不過這看起來好像也不是什麼大問題,只要直接建立兩個完全獨立的類別,然後分別實作各種方法,像是 hit, pitch, serve 等等。

但是我們還是希望可以稍微整理一下,讓這個共同的方法在某種程度上被「抽取出來」或「規範」,並在未來建立其他新的類別的時候可以被使用。

接下來我們來看看幾種不同的實作方式:

1. 建立 parent 類別

class Athlete {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit`)
  }
}

class BaseballPlayer extends Athlete {
  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

class TennisPlayer extends Athlete {
  serve() {
    console.log(`${this.name} can serve`)
  }
}

這裡我們建立了一個 Athlete 類別,並讓 BaseballPlayerTennisPlayer 分別繼承他的屬性和方法,如此一來,baseball player 和 tennis player 都可使用同樣的 hit 方法

const jeter = new BaseballPlayer('jeter')
const federer = new TennisPlayer('federer')
const someone = new Athlete('someone')

jeter.hit()     // jeter can hit
federer.hit()   // federer can hit
someone.hit()   // someone can hit

不過這時候又發現了幾個小問題,一個是雖然我們希望 baseball player 和 tennis player 都可以使用 hit,但是兩者實際實作 hit 的方式和細節可能不太一樣,譬如我們希望變成

jeter.hit()     // jeter can hit baseball
federer.hit()   // federer can hit tennis

另外一個是,也許我們不需要為 Athlete 建立實例 (e.g., someone)。我們期待各種運動選手都應該來自於各種選手的類別,然後這些類別共同繼承Athlete

2. 使用「抽象類別」

為了解決剛才提到的問題,所以現在我們換個方式做。相對於建立一個 parent 類別,這裡我們建立一個抽象類別 Athlete

abstract class Athlete {
  name: string

  constructor(name: string) {
    this.name = name
  }

  abstract hit(): void;
}

class BaseballPlayer extends Athlete {
  hit() {
    console.log(`${this.name} can hit baseball`)
  }

  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

class TennisPlayer extends Athlete {
  hit() {
    console.log(`${this.name} can hit tennis`)
  }

  serve() {
    console.log(`${this.name} can serve`)
  }
}

跟剛剛不一樣的地方是,我們無法透過抽象類別來建立實例,另一方面,我們只在這個抽象類別定義了 hit 這個方法的存在,但是沒有定義 hit 的實作細節。所以 baseball player 和 tennis player 可以有同樣的 hit 方法但是有不同的結果

const jeter = new BaseballPlayer('jeter')
const federer = new TennisPlayer('federer')
const someone = new Athlete('someone')     // error

jeter.hit()     // jeter can hit baseball
federer.hit()   // federer can hit tennis

3. 使用「介面」

除了抽象類別之外,我們還可以使用介面 (interface)。介面本身定義了 hit 方法的存在,但是沒有定義它的實作方式。hit 實作的方式被定義在使用 (implements) 該介面的類別當中

interface Hit {
  hit(): void;
}

class BaseballPlayer implements Hit {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit baseball`)
  }

  pitch() {
    console.log(`${this.name} can pitch the ball`)
  }
}

class TennisPlayer implements Hit {
  name: string

  constructor(name: string) {
    this.name = name
  }

  hit() {
    console.log(`${this.name} can hit tennis`)
  }

  serve() {
    console.log(`${this.name} can serve`)
  }
}

和抽象類別一樣,介面無法建立實例

const jeter = new BaseballPlayer('jeter')
const federer = new TennisPlayer('federer')
const someone = new Hit()                    // error

jeter.hit()     // jeter can hit baseball
federer.hit()   // federer can hit tennis

不過,究竟什麼是「抽象類別」和「介面」,就讓我們下一篇文章多談一些吧!


鐵人賽發表網址:幫自己搞懂物件導向和設計模式


#OOP #Object-oriented programming #TypeScript #2021-ironman







Related Posts

外出學習效果好的一天

外出學習效果好的一天

.Net MVC authorization Controller and Workcontext extension in razor view

.Net MVC authorization Controller and Workcontext extension in razor view

2020 COSCUP 感想

2020 COSCUP 感想


Comments